Skip to content

feat(app): filter for or exclude a pattern from Event Patterns - #3665

Open
alex-fedotyev wants to merge 5 commits into
alex/FR-385-compact-pattern-predicatefrom
alex/FR-385-pattern-filter-actions
Open

alex-fedotyev wants to merge 5 commits into
alex/FR-385-compact-pattern-predicatefrom
alex/FR-385-pattern-filter-actions

Conversation

@alex-fedotyev

@alex-fedotyev alex-fedotyev commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Adds Filter for this pattern and Exclude this pattern to each row of Event Patterns. Each adds a search filter that ClickHouse evaluates over the whole time range, not only the 10,000 sampled rows, so the results table, histogram and counts follow it. Part of FR-385.

Stack

Part 4 of 4. Each PR is based on the one before it, so its diff shows only its own step. Review in order:

  1. feat(common-utils): share the Event Patterns drill-down predicate #3662 Share the pattern predicate in common-utils
  2. refactor(mcp): use the shared pattern predicate for drill-downs #3663 MCP uses the shared predicate
  3. feat(common-utils): add a compact form of the pattern predicate #3664 Compact form of the predicate, short enough for a URL
  4. feat(app): filter for or exclude a pattern from Event Patterns #3665 Filter for or exclude a pattern from Event Patterns (this PR)

Please merge in order: once a PR merges, the next one is retargeted to main. CI and the tier label only run on PRs whose base is main, so PRs 2 to 4 get them at that point. Until then the test plan lists what I ran locally.

Summary

  • Hovering a pattern row shows the two buttons in the row's hover strip, with the filter icons the app uses for other filter actions (IconFilter, IconFilterX). Filter for this pattern switches to the results table. Exclude this pattern stays on Event Patterns, which mines again without it.
  • The filter is a { type: 'sql' } condition built with the compact predicate from feat(common-utils): add a compact form of the pattern predicate #3664. It strips ANSI color codes first and splits on the same delimiters and whitespace as the browser miner (Python Drain3), so it selects the events the pattern describes, colored logs included.
  • Pattern filters show as Pattern pills after the other filters (= to include, != to exclude), within the same eight visible pills. A pill's ✕ removes it, and Clear all removes them with the rest.
  • They are kept in the URL and in saved searches like any other filter.
  • The pattern drawer is unchanged.

Filters are extended, not changed

A sidebar facet change rebuilds the filter list from facet state, which drops every SQL filter the facets can't represent. useSearchPageFilterState takes a new optional isPreservedFilter, and filters it matches are appended back after a facet update. Only the search page passes it, so other callers behave as before; a test pins that without the option a facet update still drops other SQL filters. With no pattern filters, every filter path emits what it does on main.

Guards

  • One include at a time. Filters are ANDed, so two includes would match nothing: a new include replaces the previous one. Excludes stack, and flipping a pattern between include and exclude replaces its filter.
  • A source change drops pattern filters, because the condition names the old source's body expression. They go through retainFiltersByColumns, so the existing "N filters didn't apply to this source" notice counts them.
  • Templates longer than 4,000 characters get no buttons, the same limit MCP uses.

Known limitations

  • The pattern drawer has no actions yet; it is due for a redesign.
  • The buttons live in the row's hover strip, like the results table's copy buttons, so they can't be reached from the keyboard yet. Keyboard access belongs with the drawer redesign.
  • Timestamps in a body that Drain keeps as literal tokens tie the pattern to that date and hour (2026 04 17T16 in the screenshots), so the filter misses events of the same shape from other hours.
  • Very generic templates such as <*> <*> match more events than the row suggests.
  • The filter strips CSI escape sequences (colors, cursor moves), the ones logs carry. The miner also strips rarer kinds, such as OSC hyperlinks, so a body with those doesn't match its pattern.
  • The compact form costs about 2x query time on exclusion filters (feat(common-utils): add a compact form of the pattern predicate #3664).
  • Pill text drops punctuation, because it shows the template.
  • Dashboard pattern tiles don't get the buttons.
  • UI and MCP still mine with different delimiters, so they produce different templates for the same logs, even though both filters now come from one function.

Test plan

  • unit: searchFilters.test.ts (preserved filters survive a facet update; clear all and a source change drop them; without the option a facet update still drops other SQL filters), ActiveFilterPills.test.tsx (a pattern pill renders and its ✕ calls the handler; pattern pills count toward the eight visible pills), patternFilter.test.ts (build and parse, including a quoted value expression; run on bodies the way ClickHouse does, including color codes and Python-only whitespace; only filters it built read back; no filter for a template with punctuation inside a token; flips both ways, one include across columns, excludes stack), PatternRowActions.test.tsx (both buttons build the full filter; none for a template too long to filter on)
  • e2e: event-patterns.spec.ts "pattern filters", 3 tests: an exclude from a row survives a facet change; Filter for this pattern opens the results table with a Pattern = pill; a source change drops the filter with the notice. CI runs them once this PR targets main. I replayed the three flows with the same selectors on a dev stack against the OpenTelemetry demo logs:
    • exclude from a row: 347,939 to 312,989 events, the pattern leaves the list, the drawer stays closed
    • SeverityText = info keeps the exclusion, and a second exclusion stacks (244,332)
    • Filter for this pattern: results table, 22,244 events, no error
    • switching to Demo Traces: "4 filters didn't apply to this source and were removed", no pattern pills left
  • manual, on the same changes before they were split into this stack: 8 stacked exclusions (318K to 178K events), a saved search restores all 8 pills, a reload keeps them
  • manual, after the review fixes: an exclusion with the ANSI step runs (343,228 to 310,958 events, no error); seven ServiceName values and two exclusions show eight pills and "+1 more", which expands to both pattern pills. In ClickHouse, a colored body fits its template only with the ANSI step, and 8 real exclusions leave the same 177,130 events in about the same time with or without it
  • ClickHouse: the ANSI step and the Python whitespace grow the URL for 8 exclusions from 11,010 to 12,786 characters
  • states. Loading: as with any filter change, the previous list stays on screen until the new sample comes back (about 1 s after a facet change, 2.5 to 3 s after an exclusion here, since the regex is slower), then the table's "Loading results" footer shows while it mines; the new pill shows right away. Empty: a time range without events shows "No results found" and no buttons. Error: a pattern filter that ClickHouse rejects (a hand-edited URL naming a missing column) shows the existing query error, and removing its pill recovers
  • viewport: at 1440 x 900 and 1280 x 800 the buttons fit in the row. At 1024 x 768 the results pane is wider than the window, so these buttons and the results table's existing copy buttons both sit past its right edge
  • light and dark mode, screenshots below
  • tsc --noEmit; eslint with no warnings on added lines; the 74 app suites related to the changed files: 71 pass (1,390 tests), and the other 3 (SearchWhereInput, DBSearchPageAlertModal, DBEditTimeChartForm) fail the same 43 tests on main in my environment (TypeError reading addEventListener while rendering Mantine notifications); knip; yarn dupes

PatternRowActions has no story: it is two existing DBRowTableIconButtons inside the table's hover strip, covered by a unit test and the e2e tests above.

Screenshots

Row buttons on hover, light and dark:

light row actions

dark row actions

Two exclusions next to a facet filter, with the list mined again without them, light and dark:

light pills

dark pills

After Filter for this pattern on top of those, light:

light include

Event Patterns groups a 10,000-row sample into patterns, but a pattern
could not be acted on: there was no way to see every event that fits it,
or to hide it and look at what is left.

Hovering a pattern row now shows two buttons. "Filter for this pattern"
adds a filter for the events that fit the pattern and switches to the
results table. "Exclude this pattern" adds the negated filter and stays on
Event Patterns, which mines again without it. The filter is a SQL condition
built with the shared compact pattern predicate, so it runs in ClickHouse
over the whole time range, and the histogram and counts follow it.

Pattern filters show as pills next to the other filters and are kept in
the URL and in saved searches like any other filter. A sidebar facet change
rebuilds the filter list from facet state, which would drop them, so
useSearchPageFilterState takes an isPreservedFilter option that keeps
matching filters through facet changes. Only the search page passes it;
other callers behave as before, and a test pins that. "Clear all" still
removes them.

Two guards keep the workflow out of broken states. A new include replaces
the previous one, because two includes AND together and match nothing. A
source change drops pattern filters through the existing "filters didn't
apply" notice, because the condition names the old source's body
expression.

Part of FR-385.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Nov 13, 2026 11:46pm UTC
hyperdx-storybook Ready Ready Preview Nov 13, 2026 11:46pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 26c9359

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Minor
@hyperdx/api Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

3 finding(s): 🔴 0 critical · 🟠 0 major · 🔵 3 minor

2 posted as inline comment(s) on the changed lines. 1 listed below.

Findings outside the changed lines

1 minor
  • 🔵 packages/app/src/__tests__/searchFilters.test.ts:118 — The preserved-filter hook tests never use the real isPatternFilter, so they can't catch a pattern condition that the facet parser also picks up → The tests match by identity (f => f === pattern) on a condition like match(Body, '^a$'), not on a real pattern filter. Add a case that passes isPatternFilter and a filter from buildPatternFilter, including a template with a word like IN. Then assert that a facet update emits that filter exactly once. This pins the hook's documented rule, "or they'd be emitted twice", against what parseQuery/extractInClauses in packages/common-utils/src/filters.ts actually do.

Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

Report-only multi-agent review of the pattern filter row-actions feature. 12 reviewers ran; findings below are merged, deduplicated, and re-graded against a ship-blocker bar. The change is clean for its surface area — all seven prior-review items are confirmed addressed, and security, performance, type-safety, and project-standards passes found nothing blocking.

✅ No critical issues found. No P0/P1: the state-timing concerns raised do not have a human-reachable reproduction (three reviewers independently confirmed React's passive-effect ordering protects normal click-to-click interactions), and no SQL-injection, crash, or data-loss path was constructable.

🟡 P2 — recommended

  • packages/app/src/searchFilters.tsx:131 — preservedFiltersRef is written only inside useEffect([searchQuery]), so it trails the committed query by one commit; if a facet mutator ever emits before that effect flushes it would re-append a just-removed pattern filter or drop a just-added one.
    • Fix: Derive preserved filters synchronously inside updateFilterQuery (e.g. searchQuery.filter(isPreservedFilter)) instead of reading an effect-lagged ref.
    • julik-frontend-races, correctness, kieran-typescript, adversarial
  • packages/app/src/components/DBRowTable.tsx:1347 — the getRowWhere && !rowActions branch makes rowActions suppress the entire DBRowTableRowButtons block (copy, wrap-lines toggle, side-panel open), not just the copy buttons the prop comment describes, so the next caller that passes both props silently loses functionality.
    • Fix: Broaden the rowActions prop doc to list everything it replaces, or render rowActions alongside the wrap/side-panel controls rather than fully replacing the button group.
  • packages/app/src/components/Patterns/patternFilter.ts:1 — removePatternFilter's condition-equality removal (new behavior) has no unit coverage; ActiveFilterPills.test only asserts the handler fires on a mock, and the end-to-end remove path is exercised only by the base-gated e2e.
    • Fix: Add a unit test asserting removal drops only the matching pattern condition while preserving facet filters and other pattern filters.
    • testing, correctness, adversarial
  • packages/app/src/hooks/usePatterns.tsx:310 — the new template: clusterTemplates?.[patternId] ?? lastRow?.__hdx_pattern field and the consuming typeof row.template === 'string' guard in PatternTable are untested; ?? also preserves an empty-string template, which passes the guard and builds a degenerate ^ ? ?$ predicate.
    • Fix: Cover the clusterTemplates / __hdx_pattern fallback and missing/empty/non-string template cases (no row actions, no degenerate filter).
    • testing, correctness, adversarial
🔵 P3 nitpicks (5)
  • packages/app/src/DBSearchPage.tsx:1494 — two distinct templates that normalize to the same compact regex are both kept (they differ by template identity) but share one condition string, so removing one exclude pill removes both filters, and a co-resident hand-authored SQL filter with the same text is removed too.
    • Fix: Have removePatternFilter match a single filter by (template, valueExpression, action) identity rather than by condition-string equality.
  • packages/app/src/components/ActiveFilterPills.tsx:308 — key={filter.condition} is not unique when two pattern filters share a condition, producing duplicate React keys and a mismatched onRemove target.
    • Fix: Append the map index to the key, e.g. key={`${filter.condition}-${i}`}.
  • packages/app/src/DBSearchPage.tsx:1500 — addPatternFilter/removePatternFilter close over the useWatch filters snapshot, so two pattern-row clicks faster than a re-render let the second overwrite the first.
    • Fix: Read the current list via getValues('filters') at call time, or funnel pattern add/remove through a single reducer.
  • packages/app/src/DBSearchPage.tsx:1504 — the action === 'include' → setAnalysisMode('results') switch (and the exclude branch that stays) is asserted only by the base-gated e2e, so it has no executing coverage on this branch.
    • Fix: Add a unit/integration test that include switches to the results view and exclude does not.
  • packages/api/src/mcp/tools/query/runEventPatterns.ts:565 (outside this diff) — the new "Exclude this pattern" UI action has no clean MCP equivalent, since the tool surfaces only a positive whereSnippet with source/user filters ANDed in, so an agent cannot negate just the pattern predicate.
    • Fix: Expose the bare pattern predicate (or an explicit exclude snippet) and document negating it in the drilldown usage text; track as a follow-up.

Pre-existing (not counted toward verdict): packages/app/src/DBSearchPage.tsx is 3,114 lines, far over the documented 300-line guideline; the ~40 lines of new pattern-filter wiring could be extracted into a usePatternFilters hook.


Reviewers (12): correctness, testing, maintainability, project-standards, agent-native, learnings, kieran-typescript, julik-frontend-races, security, performance, adversarial, previous-comments.

Testing gaps:

  • No non-gated test exercises pattern-pill removal end-to-end or the include→results-view switch (both only in the base-gated event-patterns.spec.ts).
  • preservedFiltersRef staleness across sequential facet updates is not exercised — existing tests use a static searchQuery, so the ref is always current.
  • The fits() helper in patternFilter.test.ts re-implements ClickHouse RE2/replaceRegexpAll semantics in JS; real match equivalence is validated only by the gated e2e, so client and server predicates could drift.
  • useGroupedPatterns has no test file, leaving the new template field unverified at the hook level.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

alex-fedotyev and others added 2 commits November 13, 2026 23:15
…-pattern-filter-actions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Pattern filters strip ANSI CSI sequences (colors) before matching,
  since the miner strips ANSI codes before mining. Colored logs now match
  their pattern.
- Match Python Drain3's whitespace, which the miner splits on.
- Pattern pills count toward the eight visible pills, after the facet
  pills.
- Read the template from the row instead of a lookup map; a pattern
  without a template gets no buttons.
- Build the delimiter list from the shared ASCII_PUNCTUATION.
- Assert the full filter for both row buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-fedotyev

Copy link
Copy Markdown
Contributor Author

Review findings outside the inline threads:

  • ANSI codes (major): fixed in 819afc5. The filter strips CSI sequences before matching, so colored logs match their pattern, and the parser only accepts filters built that way. In ClickHouse a colored body fits its template only with that step, and 8 real exclusions leave the same events in about the same time with or without it. Rarer escape kinds the miner also strips, such as OSC hyperlinks, are listed under known limitations.
  • The exclude assertion now checks the full filter: fixed in 819afc5.
  • A pattern without a template gets no buttons instead of a throw: fixed in 819afc5.
  • removePatternFilter removes every filter with the pill's condition on purpose: an identical condition is the same pattern filter (parsePatternFilter accepts it), and keeping a copy would leave the pattern applied after its pill is gone.

isPreservedFilter: isPatternFilter,
});

const patternFilters = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Two separate derivations of the pattern-filter set

useSearchPageFilterState already splits pattern filters out of filters through isPreservedFilter (searchFilters.tsx:129). DBSearchPage then parses filters again for the pills and adds its own remove/add handlers inline. Have the hook return the preserved filters and a remover, or move this into a usePatternFilters hook in patternFilter.ts, so the pills and the facet-preservation logic use one source.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

searchFilters.tsx stays pattern-agnostic on purpose: it only knows isPreservedFilter, and the page derives the pills from the same filters the hook reads, so the two always agree.

alex-fedotyev and others added 2 commits November 13, 2026 23:40
…-pattern-filter-actions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A value expression with quotes reads back.
- A template with punctuation inside a token gets no filter; the miner's
  templates never have one.
- Flips both ways, and one include across columns.
- Say which filters isPreservedFilter may match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-fedotyev

Copy link
Copy Markdown
Contributor Author

Second-round findings outside the inline threads:

  • Keyboard access: the buttons reuse the table's hover strip, like the results table's copy buttons (tabIndex={-1}, shown on hover). Making the strip keyboard-reachable changes every table, so it belongs with the drawer redesign; it's listed under known limitations.
  • Round trip with quotes (P2): fixed in 26c9359, with a quoted value expression. A template with punctuation inside a token gets no filter, and the miner never produces one.
  • preservedFiltersRef timing (P2): same as knownColumnsRef next to it. The ref catches up on every commit, and facet and pattern changes come from separate clicks, so they never share a flush.
  • Flips both ways and an include on another column: tested in 26c9359.
  • What isPreservedFilter may match: documented in 26c9359.
  • CSI-only stripping: listed under known limitations.
  • Parsing the ANSI wrapper with startsWith/endsWith: the round trip is pinned by tests, so a change in its shape fails them.
  • keepPreserved on by default: only the search page passes isPreservedFilter, and it wants preserved filters kept on every update except clear all and a source change, the two calls that opt out.

That's the second round of automated findings, so per AGENTS.md I'm leaving any further ones for review.

valueExpression: string;
onFilter: (filter: ConditionFilter, action: PatternFilterAction) => void;
}) {
const filters = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Each row builds the same compact predicate twice, and the exclude == null check can never fail on its own

buildPatternFilter returns null only when buildCompactPatternPredicate returns null, and include and exclude are built from that same predicate. Build the include filter once and derive the exclude from it, for example with an invertPatternFilter helper in patternFilter.ts that adds the NOT prefix. Then drop the separate exclude == null check. This halves the regex work on every pattern row mount.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

isPreservedFilter: isPatternFilter,
});

const patternFilters = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Pattern-filter state (parse list, add, remove) is hand-written inside DBSearchPage, which also tracks the same filters a second way

useSearchPageFilterState already collects the same set through isPreservedFilter into preservedFiltersRef, so these filters now have two copies that must stay in sync. Move patternFilters, addPatternFilter and removePatternFilter into a usePatternFilters(filters, handleSetFilters) hook in components/Patterns/patternFilter.ts, or expose the preserved filters from useSearchPageFilterState. That keeps pattern-filter handling in one module instead of the multi-thousand-line page.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

This branch was successfully deployed

2 active deployments
Preview – hyperdx-oss — 26c93590 Deployed Nov 13, 2026 by vercel[bot]
Preview – hyperdx-storybook — 26c93590 Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant